package edu.uw.cs.cse461.sp12.OS;

import java.util.*;

import org.json.JSONException;
import org.json.JSONObject;

import edu.uw.cs.cse461.sp12.OS.DDNSException.*;
import edu.uw.cs.cse461.sp12.OS.DDNSRRecord.RecordType;
import edu.uw.cs.cse461.sp12.OS.HTTPDService.HTTPProvider;
import edu.uw.cs.cse461.sp12.OS.RPCCallable.RPCCallableMethod;
import edu.uw.cs.cse461.sp12.util.IPFinder;
import edu.uw.cs.cse461.sp12.util.Log;

public class DDNSService extends RPCCallable implements HTTPProvider {

	private static final String TAG="DDNSService";
	
	// TODO: The DDNSService does not need to be started on all machines.  Should be config related
	//       about whether OS starts the service or not
	
	private DDNSTreeNode root;
	
	private RPCCallableMethod<DDNSService> resolve;
	private RPCCallableMethod<DDNSService> register;
	private RPCCallableMethod<DDNSService> unregister;
	
	public DDNSService() throws Exception {
		resolve = new RPCCallableMethod<DDNSService>(this, "resolve");
		((RPCService)OS.getService("rpc")).registerHandler(servicename(), "resolve", resolve);
		register = new RPCCallableMethod<DDNSService>(this, "register");
		((RPCService)OS.getService("rpc")).registerHandler(servicename(), "register", register);
		unregister = new RPCCallableMethod<DDNSService>(this, "unregister");
		((RPCService)OS.getService("rpc")).registerHandler(servicename(), "unregister", unregister);

		Properties config = OS.config();
		String hostName = config.getProperty("host.name");
		String hostPassword = config.getProperty("host.password");
		int hostPort = Integer.parseInt(config.getProperty("rpc.serverport"));
		// Set up root node (SOA record without an alias)
		this.root = new DDNSTreeNode(hostName, hostPassword, RecordType.SOA, null);
		
		// Register NS Zone with authority
		DDNSResolverService resolver = (DDNSResolverService) OS.getService("ddnsresolver");
		resolver.addContinuousRegistration(this.root.getRecord().getFullName(), hostPort, hostPassword);
		
		// Set up our tree
		for (int i = 1; config.containsKey("ddns.child" + i + ".name"); i++) {
			// Add the child
			String name = config.getProperty("ddns.child" + i + ".name");
			String password = config.getProperty("ddns.child" + i + ".password");
			RecordType type = RecordType.valueOf(config.getProperty("ddns.child" + i + ".type"));
			String alias = null;
			if (type == RecordType.CNAME) {
				alias = config.getProperty("ddns.child" + i + ".alias");
			}
			DDNSTreeNode childNode = new DDNSTreeNode(name, password, type, alias);
			DDNSFullName fullName = childNode.getRecord().getFullName();

			// Set node to be the parent node of our new child
			DDNSTreeNode node = root;
			int j = 0;
			List<String> parts = fullName.fromRoot(this.root.getRecord().getFullName());
			for(String part : parts) {
				// Go down the tree until get to parent
				j++;
				if (j != parts.size()) {
					node = node.getChild(part);
				}
			}
			// Add the child to the parent node
			node.addChild(fullName.getAddressName(), childNode);
			String portString = config.getProperty("ddns.child" + i + ".port");
			if (type == RecordType.A && portString != null) {
				int port = Integer.parseInt(portString);
				// This is an A record that should always be registered
				resolver.addContinuousRegistration(childNode.getRecord().getFullName(), port, password);
			}
		}
	}
	
	@Override
	public void shutdown() {
		try {
			DDNSResolverService resolver = (DDNSResolverService) OS.getService("ddnsresolver");
			resolver.unregister(this.root.getRecord().getFullName());
		} catch (Exception e) {
			Log.d(TAG, "Exception while shutting down: " + e.getMessage());
		}
	}

	@Override
	public String httpServe(String[] uriArray) {
		StringBuilder builder = new StringBuilder();
		
		try {
			if (uriArray.length == 2) {
				// Just dump out the tree
				builder.append("Zone:\n");
				printTree(builder, root, 0);
			} else if (uriArray.length == 4 || 
						uriArray.length == 5 ||
						uriArray.length == 7) {
				// Trying to call a resolve/register/unregister.  Do it and print out the resulting JSON
				String command = uriArray[2];
				DDNSFullName name = new DDNSFullName(uriArray[3]);
				
				builder.append("Command: ");
				builder.append(command);
				builder.append("\n");
				builder.append("\tName: ");
				builder.append(name);
				builder.append("\n");
				builder.append("\tResult:\n");
				builder.append("\t\t");
				
				if (command.equals("resolve") && uriArray.length == 4) {
					builder.append(_resolve(name).toString());
				} else if (command.equals("register") && uriArray.length == 7) {
					String ip = uriArray[4];
					int port = Integer.parseInt(uriArray[5]);
					String password = uriArray[6];

					builder.append(_register(name, ip, port, password).toString());
				} else if (command.equals("unregister") && uriArray.length == 5) {
					String password = uriArray[4];

					builder.append(_unregister(name, password).toString());
				} else {
					builder.append("\n\nERROR: Command not known");
				}
			} else {
				// Unknown command
				builder.append("Unknown command\n");
				for(int i = 0; i < uriArray.length; i++) {
					builder.append("Arg ");
					builder.append((i + 1));
					builder.append(": ");
					builder.append(uriArray[i]);
					builder.append("\n");
				}
			}
		} catch (Exception e) {
			builder.append("\n\nException: " + e.getMessage());
		}
		
    	return builder.toString();
	}
	
	private void printTree(StringBuilder builder, DDNSTreeNode node, int depth) {
		for(int i = 0; i < depth; i++) {
			builder.append("   ");
		}
		builder.append("'");
		builder.append(node.getRecord().getFullName().toString());
		builder.append("'");
		builder.append("\t");
		builder.append("[");
		builder.append(node.getRecord().toString());
		builder.append("]");
		builder.append("\t Lifetime: ");
		builder.append(node.getLifetime());
		builder.append("\n");
		
		for(DDNSTreeNode child : node.children.values()) {
			printTree(builder, child, depth + 1);
		}
	}

	private DDNSTreeNode findNode(DDNSFullName fullName) throws DDNSException {
		// Work our way through the tree
		List<String> parts = fullName.fromRoot(this.root.getRecord().getFullName());
		DDNSTreeNode node = this.root;
		DDNSRRecord record = node.getRecord();
		for(String part : parts) {
			if (record.getType() == RecordType.CNAME) {
				// Found a CNAME.  Stop resolution and return the record
				break;
			} else if (record.getType() == RecordType.NS) {
				// Found a NS.  Stop resolution and return the record
				break;
			}
			
			// Go down the tree
			if (node.hasChild(part)) {
				node = node.getChild(part);
			} else {
				throw new DDNSNoSuchNameException(fullName.getName() + " could not be found in " + record.getFullName().toString());
			}
			record = node.getRecord();
		}
		// Reached the end of resolution.  Either through a CNAME/NS or by resolving the name completely
		return node;
	}
	
	private boolean isDone(DDNSTreeNode node, DDNSFullName name) {
		return (node.getRecord().getFullName().equals(name) &&
				(node.getRecord().getType() == RecordType.A ||
				 node.getRecord().getType() == RecordType.SOA));
	}
	
	public JSONObject resolve(JSONObject args) throws JSONException {
		DDNSFullName name = new DDNSFullName(args.getString("name"));
		return _resolve(name);
	}
	
	private JSONObject _resolve(DDNSFullName name) throws JSONException {
		JSONObject result = new JSONObject();
		try {
			try {
				boolean done = false;
	
				DDNSTreeNode node = findNode(name);
				
				if (isDone(node, name)) {
					// Found name.  Done with resolving
					done = true;
				}
				
				// Everything worked.  Set the result
				setResultNoError(result, node, done, DDNSResolverService.RPC_RESPONSE_RESOLVE);
			} catch (DDNSException e) { 
				// Keep throwing it so catch outside
				throw e;
			} catch (Exception e) {
				// Wrap all other exceptions as a DDNSRuntimeException
				throw new DDNSRuntimeException(e.getMessage());
			}
		} catch (DDNSException e) {
			setResultError(result, e, name);
		}
		return result;
	}

	public JSONObject register(JSONObject args) throws JSONException {
		DDNSFullName name = new DDNSFullName(args.getString("name"));
		String ip = args.getString("ip");
		int port = args.getInt("port");
		String password = args.getString("password");
		return _register(name, ip, port, password);
	}
	
	private JSONObject _register(DDNSFullName name, String ip, int port, String password) throws JSONException {
		JSONObject result = new JSONObject();
		try {
			try {
				boolean done = false;
				
				DDNSTreeNode node = findNode(name);
				
				if (node.getRecord().getFullName().equals(name) &&
					node.getRecord().getType() != RecordType.CNAME) {
					// Register if not a CNAME and name matches
					node.setRecord(ip, port, password);
				}
				
				if (isDone(node, name)) {
					// Done with resolving
					done = true;
				}
				
				result.put("lifetime", node.getLifetime());
	
				// Everything worked.  Set the result
				setResultNoError(result, node, done, DDNSResolverService.RPC_RESPONSE_REGISTER);
			} catch (DDNSException e) { 
				// Keep throwing it so catch outside
				throw e;
			} catch (Exception e) {
				// Wrap all other exceptions as a DDNSRuntimeException
				throw new DDNSRuntimeException(e.getMessage());
			}
		} catch (DDNSException e) {
			setResultError(result, e, name);
		}
		return result;
	}
	
	public JSONObject unregister(JSONObject args) throws JSONException {
		DDNSFullName name = new DDNSFullName(args.getString("name"));
		String password = args.getString("password");
		return _unregister(name, password);
	}
	
	private JSONObject _unregister(DDNSFullName name, String password) throws JSONException {
		JSONObject result = new JSONObject();
		try {
			try {
				boolean done = false;
				
				DDNSTreeNode node = findNode(name);
				
				if (node.getRecord().getFullName().equals(name) &&
						node.getRecord().getType() != RecordType.CNAME) {
					// Found a matching name.  Unregister
					node.clearRecord(password);
				}
				
				if (isDone(node, name)) {
					// Done with resolving. Set Done to true and return no node
					node = null;
					done = true;
				}
				
				// Everything worked.  Set the result
				setResultNoError(result, node, done, DDNSResolverService.RPC_RESPONSE_UNREGISTER);
			} catch (DDNSException e) { 
				// Keep throwing it so catch outside
				throw e;
			} catch (Exception e) {
				// Wrap all other exceptions as a DDNSRuntimeException
				Log.d(TAG, "Exception: " + e.getMessage());
				throw new DDNSRuntimeException(e.getMessage());
			}
		} catch (DDNSException e) {
			setResultError(result, e, name);
		}
		return result;
	}
	
	private void setResultNoError(JSONObject result, DDNSTreeNode node, boolean done, String resulttype) throws DDNSException, JSONException {
		if (node != null) {
			if (!node.getRecord().isRegistered()) {
				// Either IP/Port or ALIAS is not set up correctly
				throw new DDNSNoAddressException("No address exists for name " + node.getRecord().getFullName().toString());
			}
			
			result.put("node", node.getRecord().toJSON());
		}
		result.put("done", done);
		result.put("resulttype", resulttype);
	}
	
	private void setResultError(JSONObject result, DDNSException e, DDNSFullName name) throws JSONException {
		result.put("resulttype", DDNSResolverService.RPC_RESPONSE_DDNSEXCEPTION);
		
		result.put("exceptionnum", e.getExceptionNum());
		result.put("message", e.getMessage());
		
		if (!(e instanceof DDNSRuntimeException)) {
			// Only put name if not a DDNSRuntimeException
			result.put("name", name.toString());
		}
		
		if (e instanceof DDNSZoneException) {
			// Add a zone if a DDNSZoneException
			result.put("zone", ((DDNSZoneException) e).getZone());
		}
	}
	
	@Override
	public String servicename() {
		return "ddns";
	}

	private class DDNSTreeNode {
		public static final long UNREGISTERED_TIME = -1;
		
		private Map<String, DDNSTreeNode> children;
		private DDNSRRecord record;
		private String password;
		private long registeredTime;
		
		private DDNSTreeNode(String fullName, String password, RecordType nodeType, String alias) throws DDNSException {
			this.children = new HashMap<String, DDNSTreeNode>();
			if (nodeType == RecordType.CNAME) {
				// It is a CNAME record.  Use the alias
				this.record = new DDNSRRecord(fullName, alias);
			} else {
				// Make a record of the given type with IP and Port not set
				this.record = new DDNSRRecord(fullName, nodeType, DDNSRRecord.IP_NOT_SET, DDNSRRecord.PORT_NOT_SET);
			}
			if (password == null) {
				this.password = "";
			} else {
				this.password = password;
			}
			// Start out as unregistered
			this.registeredTime = UNREGISTERED_TIME;
		}
		
		private void clearRecord(String password) throws DDNSException {
			setRecord(DDNSRRecord.IP_NOT_SET, DDNSRRecord.PORT_NOT_SET, password);
			// Unregister the node
			this.registeredTime = UNREGISTERED_TIME;
		}
		
		private void setRecord(String ip, int port, String password) throws DDNSException {
			if (this.password.equals(password)) {
				// Valid request.  Change the record
				this.record.setIP(ip);
				this.record.setPort(port);
				// Register the node
				this.registeredTime = System.currentTimeMillis();
			} else {
				throw new DDNSAuthorizationException("Invalid password");
			}
		}
		
		private DDNSRRecord getRecord() {
			checkRegistration();
			
			return this.record;
		}
		
		/**
		 * NOTE: Should only  be called on boot
		 * @param name
		 * @param child
		 */
		private void addChild(String childName, DDNSTreeNode child) {
			checkRegistration();
			
			this.children.put(childName, child);
		}
		
		private DDNSTreeNode getChild(String childName) throws DDNSException {
			// Check lifetime of this
			this.checkRegistration();
			
			DDNSTreeNode child = this.children.get(childName);
			
			if (child == null) {
				throw new DDNSNoSuchNameException(childName + " could not be found in " + record.getFullName().toString());
			}
			
			// Check lifetime of child
			child.checkRegistration();
			
			return child;
		}
		
		private boolean hasChild(String childName) {
			checkRegistration();
			
			return this.children.containsKey(childName);
		}
		
		private int getLifetime() {
			int maxLifeTime = Integer.parseInt(OS.config().getProperty("ddns.lifetime"));
			
			return DDNSResolverService.getLifetime(maxLifeTime, this.registeredTime);
		}
		
		private void checkRegistration() {
			if (this.record.getType() != RecordType.CNAME) {
				int maxLifeTime = Integer.parseInt(OS.config().getProperty("ddns.lifetime"));
				if (DDNSResolverService.registrationExpired(maxLifeTime, this.registeredTime)) {
					try {
						this.clearRecord(this.password);
					} catch (DDNSException e) {
						throw new RuntimeException(e);
					}
				}
			}
		}
	}
}
